들어가며
이벤트 참여 API 에서 이상한 패턴이 보인다는 프론트엔드 개발자 분의 제보에 코드를 분석했다.
동일한 사용자의 요청이 1초도 안 되는 시간에 3번 들어왔는데 첫 두 요청은 409(중복 요청)으로 응답되고 마지막 세 번째 요청만 200 OK로 성공한 것이다.
// 09:52:15.443841 - 첫 번째 요청 { "status": 409, "latency": "45.60829ms" } // 09:52:15.444325 - 두 번째 요청 (0.5ms 후) { "status": 409, "latency": "44.822451ms" } // 09:52:15.444592 - 세 번째 요청 (0.75ms 후) { "status": 200, "latency": "180.536595ms" } // 왜 성공?
중복 요청 방지 로직이 제대로 작동했다면 첫 번째 요청만 200 OK가 나와야 했지만 오히려 첫 두 요청은 실패하고 마지막 요청만 성공했다. 문제 분석 결과 기존 코드의 Get-Then-Set 패턴의 Race Condition이 원인임을 발견했다.
기존 문제: Get & Set 으로 중복 요청 체크했을 때의 Race Condition
기존 코드는 아래와 같이 작성돼 있다.
func (u *UseCase) checkDuplicatedClick(ctx context.Context, transactionID string) error { var duplicatedClick bool // 1단계: Redis 읽기 if err := u.cache.GetCache(ctx, transactionID, &duplicatedClick); err != nil { if !errors.Is(err, cache.ErrCacheMiss) { return fmt.Errorf("get cache: %w", err) } } // 2단계: 중복 체크 if duplicatedClick { return domain.ErrMultipleClick } // 3단계: Redis 쓰기 err := u.cache.SetCache(ctx, transactionID, true, 1*time.Minute) if err != nil { return fmt.Errorf("failed to set dup click action: %w", err) } return nil }
캐시를 확인하고 중복이면 에러를 반환, 그렇지 않으면 캐시에 기록한다. 얼핏 보면 문제가 없어 보인다. 이는 Check-Then-Set 패턴으로 1단계(Get Cache)와 3단계(Set Cache)가 별도의 명령으로 실행된다. 요청 간 텀(term) 이 긴 경우 중복 요청을 방지할 수 있지만 1초 내의 짧은 간격에 여러 요청이 들어오면 두 명령 사이에 다른 요청이 끼어들 수 있어 원자성(atomicity)이 보장되지 않는다.
Race Condition 발생 시나리오
동일한 사용자가 동시에 3개의 요청을 보냈을 때 다음과 같은 일이 발생한다:
T=0ms: 요청A, B, C 거의 동시 도착 T=5ms: 요청A: GetCache → miss (캐시 없음) 요청B: GetCache → miss (A가 아직 SetCache 전) 요청C: GetCache → miss T=10ms: 요청A: SetCache(true) 요청B: SetCache(true) (덮어씀) 요청C: SetCache(true) (덮어씀) // 세 요청 모두 checkDuplicatedClick 통과 T=15ms C 가 가장 먼저 로직을 통과하며 DB 에 트랜잭션 기록 -> 200 반환 이후 A, B 는 DB 에 트랜잭션 기록된 내용을 보고 중복 요청으로 처리 -> 409 반환
Redis SETNX 로 안전하게 중복 요청 방지하기
위 문제를 해결하려면 Check와 Set을 원자적으로(atomically) 수행해야 한다.
Redis의 SETNX (SET if Not Exists) 는 이를 수행하기 위해 설계된 명령어다.
1. SETNX로 중복 요청 방지
Redis의 SETNX 는 특정 key가 없을 경우에만 값을 설정(set)한다.
ok, err := rdb.SetNX(ctx, key, true, 1*time.Second).Result()
동일한 키로 1초 동안 여러 번 호출하더라도 최초 요청만 성공하고 나머지는 실패한다. 이 구조를 흔히 "Fail-Fast" 라 부른다.
개선된 코드
func (u *UseCase) checkDuplicatedClick(ctx context.Context, transactionID string) error { // SETNX로 원자적 연산 success, err := u.cache.SetNX(ctx, transactionID, true, 1*time.Minute) if err != nil { return fmt.Errorf("failed to set dup click lock: %w", err) } // success가 false면 키가 이미 존재 = 중복 클릭 if !success { return domain.ErrMultipleClick } return nil }
SETNX 의 장점:
- 원자성: 단일 Redis 명령으로 Check와 Set이 동시에 실행
- 경쟁상태 방지: 첫 번째 요청만 키 생성하고 true 반환
- 간결함: 3단계 로직 → 1단계로 간소화
동작 방식
T=0ms: 요청A, B, C 동시 도착 T=5ms: 요청A: SetNX → 성공 (키 생성) → 계속 진행 요청B: SetNX → 실패 (키 존재) → 409 반환 요청C: SetNX → 실패 (키 존재) → 409 반환 T=50ms: 요청A만 정상 처리 → 200 반환 요청B, C는 이미 409로 반환됨
- 첫 번째 요청: SETNX 성공 → 처리 진행
- 이후 1초 내 요청: SETNX 실패 → 중복 요청 차단
2. ReleaseLock은 필요할까?
중복 요청 방지 목적이라면 애써 얻은 락(key)을 delete 해서는 안 된다.
작업이 끝났으면 key를 삭제해서 락을 해제해야 한다고 생각할 수 있지만, SETNX 를 중복 방지용도로 사용한다면 DEL(key) 은 오히려 중복을 허용하는 부작용을 낳는다.
1. 첫 요청 → SETNX 성공 2. 작업 완료 후 key를 삭제 3. TTL이 남아 있어야 하는데 바로 삭제 4. 같은 TTL 안에 들어온 두 번째 요청이 다시 SETNX 성공 → 중복 처리 발생
정리하면:
중복 요청 방지에서는 key를 삭제하지 않고, TTL로 자연스럽게 만료되는 것이 맞다.
테스트 결과: 100개 동시 요청에서도 완벽한 동작
SETNX를 적용한 후, Go의 goroutine을 사용해 동시성 테스트를 진행했다.
func TestCheckDuplicatedClickConcurrency(t *testing.T) { var wg sync.WaitGroup results := make(chan error, 100) for i := 0; i < 100; i++ { wg.Add(1) go func() { defer wg.Done() success, err := cache.SetNX(ctx, transactionID, true, 1*time.Minute) if err != nil { results <- err } else if !success { results <- domain.ErrMultipleClick } else { results <- nil } }() } wg.Wait() close(results) // 결과 집계 successCount := 0 duplicateCount := 0 for err := range results { if err == nil { successCount++ } else if errors.Is(err, domain.ErrMultipleClick) { duplicateCount++ } } // 검증: 정확히 1개만 성공, 99개는 중복 assert.Equal(t, 1, successCount) assert.Equal(t, 99, duplicateCount) }
테스트 결과
=== RUN TestCheckDuplicatedClickConcurrency === RUN TestCheckDuplicatedClickConcurrency/High_concurrency_stress_test_(100_requests) luckybox_test.go:3032: High concurrency test completed in 20.458542ms luckybox_test.go:3033: Success: 1, Duplicates: 99 --- PASS: TestCheckDuplicatedClickConcurrency (0.02s) PASS
100개의 동시 요청 중 정확히 1개만 성공하고, 나머지 99개는 모두 중복으로 거부되었다. 처리 시간은 불과 20ms로, 성능 저하 없이 완벽한 동시성 제어가 가능함을 확인했다.
Before & After 비교
| 항목 | Before (Get + Set) | After (SETNX) |
|---|---|---|
| 원자성 | 없음 (race condition 발생) | 보장 |
| Redis 호출 수 | 2회 (GET + SET) | 1회 (SETNX) |
| 동시 요청 처리 | 비정상 (마지막 요청이 성공) | 정상 (첫 요청만 성공) |
| 코드 복잡도 | 높음 (3단계 로직) | 낮음 (1단계 로직) |
| 성능 | 느림 (2번 네트워크 왕복) | 빠름 (1번 네트워크 왕복) |
결론
Get-Then-Set 패턴의 Race Condition은 겉으로는 잘 작동하는 것처럼 보이지만 동시 요청이 몰리는 순간 요청 순서가 꼬이는 등 예상치 못한 결과를 낸다. Redis의 SETNX는 원자성과 간결함 모두를 얻을 수 있는 솔루션으로 Get-Then-Set 패턴의 문제를 보완할 수 있다. 결국 분산 시스템에서 동시성 문제를 다룰 때는 "원자적으로 처리할 수 있는가?"를 먼저 고민해야 한다.
- 무의식적으로 분산락이라는 단어를 사용해서 락이 분산되어 적용된다는 의미인가 생각했는데, 분산이라는 이름은 분산 서버를 의미하는거지 레디스 인스턴스마다 락이 분산되어 걸린다는 뜻이 아니다.